Skip to content

Stats: show a pricing grid instead of the dashboard for new sites without a plan - #113366

Merged
kangzj merged 8 commits into
trunkfrom
add/stats-pricing-grid
Aug 7, 2026
Merged

Stats: show a pricing grid instead of the dashboard for new sites without a plan#113366
kangzj merged 8 commits into
trunkfrom
add/stats-pricing-grid

Conversation

@kangzj

@kangzj kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Part of STATS-366 (Calypso/Odyssey side of the pricing grid; complements Automattic/jetpack#51103).

Proposed Changes

  • In Odyssey only, wrap the traffic-page controller with a gate (withPricingGridGate): a site that first connected on or after 2026-08-07 and holds no Stats plan sees a Free vs Paid comparison in place of the dashboard. There is no separate pricing route or URL — the grid takes over the default landing until a plan is chosen.
  • The grid replicates the Jetpack Search upsell's PricingTable rendering — DOM structure and styles ported 1:1 from @automattic/jetpack-components (PricingTable, ProductPrice, Button metrics, AdminSectionHero, Container), which Calypso does not ship — using @wordpress/components primitives (Button, ExternalLink, Icon, useViewportMatch). Colors map to the studio palette tokens the jetpack theme is generated from (--studio-jetpack-green-40/50, --studio-red-50, --studio-gray-5/40); the two jetpack values with no studio equivalent are local custom properties. The same large viewport switch drives the desktop grid vs stacked mobile cards, including the Included/Not included ↔ feature-name label swap.
  • Only a plan decision dismisses the grid (recorded through the existing stats notices endpoint under a new pricing_grid id, with the cached notices patched in place so SPA route changes see the choice — via a shared useDismissPricingGrid hook):
    • Start for free on the grid → dismisses and reveals the dashboard at the same URL.
    • I will do it later on the purchase page (commercial and PWYW flows, regardless of referrer — anyone clicking it has seen the full paid pitch and deferred; a no-op on sites where the grid never shows) → dismisses before returning to the dashboard.
    • Get Paid Stats deliberately does not dismiss: reaching the purchase page is not a choice, so an abandoned checkout brings the visitor back to the grid. Completing a purchase needs no dismissal — holding a plan makes the site ineligible. (The paid CTA navigates programmatically rather than via href so the link works under Odyssey's hashbang routing.)
  • No other dashboard notice can fire alongside the grid — structurally, since the grid replaces the whole dashboard and StatsNotices never mounts alongside it. (pricing_grid is deliberately kept out of the notices conflict group: suppression there keys off server-reported visibility, which would silence every other notice on all the sites that never see the grid.)
    • Get Paid Stats/stats/purchase/:site?from=jetpack-stats-pricing-grid.
    • Start for free → dismisses in place; the dashboard renders at the same URL.
  • The pricing_grid id is reported by the wpcom notices endpoint (true until a dismissal is in effect); the client default stays hidden so a failed notices request cannot render the grid without a working dismissal round-trip.
  • Eligibility date: reads the created_at site option — verified against the wpcom codebase to be the shadow blog's wp_blogs.registered, which equals the first-connection moment when registration created the row. A reused pre-existing row keeps its older date and reconnects never update it, so the check can only withhold the grid from a genuinely new connection — never show it to an established site. (No dedicated connection-date field exists in the sites payload; the genuine timestamp lives only in jetpack_tokens_mu.issued and the audit log, neither exposed.)
  • Performance: the connection-date check is synchronous against site options, so established sites fall straight through to the dashboard with no extra requests or loading state; the purchase/notice queries only run for newly connected sites. The grid component (and its gradient asset) is code-split into an async chunk.
  • Eligibility defers to the existing useStatsPurchases hook, so bundled plans (Complete, Growth, Business) count as having Stats. Pricing comes from the Redux products list; the layout renders with the price block omitted if the product hasn't loaded.
  • Calypso's route table and pages are untouched — only the shared component directory under client/my-sites/stats/pricing-grid/ is added.

Why are these changes being made?

New Jetpack installations without a Stats plan have no in-product moment to choose between Free and Paid Stats. Automattic/jetpack#51103 adds that moment pre-connection in wp-admin; this PR adds the equivalent surface inside Odyssey for connected sites: the dashboard itself is replaced by the plan choice until one is picked, the shared notices endpoint means a choice made in either surface sticks, and reusing the Search upsell's exact pricing-table design keeps the two Jetpack upsell surfaces visually consistent.

Testing Instructions

✅ Verified end to end in Odyssey against a local Jetpack site (under the prior revision, where the paid CTA recorded the dismissal at click time): grid replaces the dashboard for an eligible site; the dismissal POST lands (200); I will do it later returns to the dashboard; Start for free reveals the dashboard in place; choices survive hard reloads (server-side persistence via the shipped pricing_grid notice support). The final revision moves the paid-path dismissal from the CTA click to the purchase page's I will do it later (same shared hook, same endpoints) — worth one more manual pass over that path.

The wpcom notices endpoint (/sites/:site/jetpack-stats-dashboard/notices) now supports the pricing_grid id, so the full flow — including dismissal persistence across reloads — is testable end to end. The client default for the id stays hidden, so a failed notices request falls through to the dashboard rather than rendering the grid without a working dismissal round-trip.

  1. Build Odyssey (cd apps/odyssey-stats && yarn dev) against a Jetpack site connected after 2026-08-07 with no Stats plan.
  2. Open wp-admin Stats — the pricing grid renders instead of the dashboard, visually matching admin.php?page=jetpack-search: title column with bold feature rows, primary column with green border, gradient header and shadow, 48px price with sup currency symbol and fraction, 'per month, from 10k monthly views, billed yearly' legend, full-width CTAs, and the Terms of Service line beneath.
  3. The four paid differentiators (UTM tracking, device stats, region & city locations, priority support) lead the rows; below 960px the table stacks into cards and labels switch to feature names.
  4. Click Start for free → the dashboard appears immediately at the same URL; reloading keeps showing the dashboard (dismissal persisted server-side).
  5. Reset the dismissal, click Get Paid Stats → lands on the purchase page with from=jetpack-stats-pricing-grid.
  6. A site connected before the launch date, or holding any Stats plan (including bundled Complete/Growth/Business), goes straight to the dashboard with no extra loading state.
  7. cd apps/odyssey-stats && yarn test:size — the grid must stay in its async chunk.
image

Pre-merge Checklist

  • Has the general commit checklist been followed? (PCYsg-hS-p2)
  • Have you written new tests for your changes?
  • Have you tested the feature in Simple (P9HQHe-k8-p2), Atomic (P9HQHe-jW-p2), and self-hosted Jetpack sites (PCYsg-g6b-p2)?
  • Have you checked for TypeScript, React or other console errors?
  • For UI changes, have you tested the affected components in dark mode?
  • Have you tested accessibility for your changes? Ensure the feature remains usable with various user agents (e.g., browsers), interfaces (e.g., keyboard navigation), and assistive technologies (e.g., screen readers) (PCYsg-S3g-p2).
  • Have you used memoizing on expensive computations? More info in Memoizing with create-selector and Using memoizing selectors and Our Approach to Data
  • Have we added the "[Status] String Freeze" label as soon as any new strings were ready for translation (p4TIVU-5Jq-p2)?
    • For UI changes, have we tested the change in various languages (for example, ES, PT, FR, or DE)? The length of text and words vary significantly between languages.
  • For changes affecting Jetpack: Have we added the "[Status] Needs Privacy Updates" label if this pull request changes what data or activity we track or use (p4TIVU-aUh-p2)?

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Looks like one of the E2E tests has failed.

You can fix them following these steps:

  1. Check out this branch locally:
    gh pr checkout 113366
  2. Start Claude Code in the repo:
    claude
  3. Run the /fix-e2e-tests skill, passing this PR number:
    /fix-e2e-tests 113366
    

@kangzj
kangzj force-pushed the add/stats-pricing-grid branch 3 times, most recently from e49d13c to f1e267d Compare August 7, 2026 02:10
@kangzj
kangzj force-pushed the add/stats-pricing-grid branch 2 times, most recently from d6c4a2e to c8a4e1b Compare August 7, 2026 02:29
@kangzj kangzj changed the title Add Stats pricing grid with dedicated route Stats: add a pricing grid page for new sites without a plan Aug 7, 2026
@kangzj
kangzj force-pushed the add/stats-pricing-grid branch from c8a4e1b to 25d01d2 Compare August 7, 2026 02:40
@kangzj kangzj changed the title Stats: add a pricing grid page for new sites without a plan Stats: show a pricing grid instead of the dashboard for new sites without a plan Aug 7, 2026
@kangzj
kangzj force-pushed the add/stats-pricing-grid branch from 25d01d2 to ba5e9d2 Compare August 7, 2026 03:52
…hout a plan

In Odyssey, the traffic page controller is wrapped with a gate: a site that
first connected on or after 2026-08-07 and holds no Stats plan sees a Free vs
Paid choice in place of the dashboard. Picking either plan reveals the
dashboard immediately and records a dismissal through the existing stats
notices endpoint (new `pricing_grid` id), so the grid stays away on later
visits. The id defaults to hidden until the server reports it — the same
ship-ahead treatment `free_site_upgrade` gets — so the grid cannot render
without a working dismissal round-trip.

The grid replicates the Jetpack Search upsell's PricingTable rendering — DOM
structure and styles ported from @automattic/jetpack-components, which Calypso
does not ship — using @wordpress/components primitives, with colors mapped to
the studio palette tokens the jetpack theme is built from.

The connection date reads the `created_at` site option: for a Jetpack site
that is the wpcom shadow blog's `wp_blogs.registered`, which matches the
first-connection moment when registration created the row; a reused
pre-existing row keeps its older date, so the check can only withhold the grid
from a new connection, never show it to an established site. Eligibility
defers to `useStatsPurchases`, so bundled plans (Complete, Growth, Business)
count as having Stats. The date check is synchronous against site options, so
established sites never wait on the purchase and notice lookups; the grid
component itself stays in an async chunk. Calypso is untouched apart from the
shared component directory.
@kangzj
kangzj force-pushed the add/stats-pricing-grid branch from ba5e9d2 to b1f14b2 Compare August 7, 2026 03:55
While the grid is undismissed it replaces the dashboard outright, so no other
dashboard notice should fire alongside it.
@kangzj
kangzj requested a review from adamwoodnz August 7, 2026 04:08
@matticbot matticbot added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 7, 2026
@kangzj
kangzj requested a review from dognose24 August 7, 2026 04:08
@kangzj kangzj self-assigned this Aug 7, 2026
@kangzj
kangzj requested a review from Nikschavan August 7, 2026 04:09
kangzj added 6 commits August 7, 2026 16:15
The wp-admin shim intercepts anchor clicks inside #wpcom with a jQuery handler
registered before React mounts, so an onClick on a link Button never ran and
clicking Get Paid Stats never recorded the dismissal. The paid CTA now
navigates programmatically after dismissing.

The dismissal mutation also never touched the notices query cache, so
returning from the purchase page via 'I will do it later' re-rendered the grid
from the stale cached visibility. Dismissing now patches the cached notices in
place, which the gate re-reads on SPA route changes.

Verified end to end in Odyssey on a local Jetpack site: both CTAs POST the
dismissal, 'I will do it later' lands on the dashboard, and the choice
survives hard reloads.
Conflict suppression runs on the server-reported visibility, not on whether
the grid actually displays, and the server reports `pricing_grid` as visible
until a dismissal is recorded. Sites that never meet the grid — everything
connected before launch, everything holding a plan — would therefore have
every other dashboard notice (GDPR consent, purchase-success, the upsells,
tier upgrade) permanently suppressed.

The grid still trumps every notice, structurally: it replaces the whole
dashboard, so StatsNotices never mounts alongside it.
Clicking Get Paid Stats no longer dismisses the grid — merely reaching the
purchase page is not a plan choice, so an abandoned checkout brings the
visitor back to the grid to choose again. The decisions that dismiss are
'Start for free' on the grid and 'I will do it later' on the purchase page
(both the commercial and PWYW flows, keyed off the pricing-grid referrer).
Completing a purchase needs no dismissal: holding a plan makes the site
ineligible for the grid.

The dismissal (mutation + notices-cache patch) moves into a shared
useDismissPricingGrid hook so all call sites stay in sync.
…errer

Anyone clicking the skip button has seen the full paid pitch and deferred, so
the grid shouldn't take over the dashboard afterwards no matter how they
reached the purchase page. On sites where the grid never shows the dismissal
is a harmless no-op.

@Nikschavan Nikschavan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, This tested well! I added some comments - Feel free to address in follow up PR the ones you agree

isEligible: isNewConnection && ! hasAnyPlan,
isNewConnection,
// The date check needs no fetch, so only newly connected sites ever wait.
isLoading: isNewConnection && isLoadingPurchases,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PURCHASES_SITE_FETCH_FAILED sets hasLoadedSitePurchasesFromServer: true and leaves the purchase list empty, so a failed lookup reads here as isLoading: false with hasAnyPlan: false. A newly connected site that already holds a plan would then get the grid instead of its dashboard. Could this fall back to ineligible when the purchases request errors?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc90308 on #113372 — a purchases fetch error now reads as "plan state unknown" and eligibility falls back to the dashboard instead of treating the empty list as "no plan".

const showPriceFraction = ! hidePriceFraction || ! fraction.endsWith( '00' );
return (
<p className="stats-pricing-grid__price">
<sup className="stats-pricing-grid__price-symbol">{ symbol }</sup>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getCurrencyObject also returns symbolPosition, which PlanPrice reads to decide which side to render on. Locales like de-DE with EUR put the symbol after the amount, so fixing it before the integer will render incorrectly there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc90308 on #113372 — the price now respects getCurrencyObject's symbolPosition, so after-symbol locales like de-DE EUR render correctly.

@media (min-width: 960px) {
padding-left: var(--padding);
padding-right: var(--padding);
white-space: nowrap;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nowrap combined with overflow: hidden means a locale whose sentence is wider than the container loses the end of it instead of wrapping. This is the Terms of Service and data-sync disclosure, and DE and PT both run longer than EN here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc90308 on #113372 — dropped the jetpack component's nowrap/overflow: hidden so the disclosure wraps in longer locales instead of truncating.

// page's "I will do it later" records the dismissal instead. Navigate
// programmatically rather than via href so the link also works under Odyssey's
// hashbang routing when the wp-admin click shim doesn't apply (e.g. middle-click).
const goToPurchase = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no Tracks event for the grid being viewed or for either CTA — the referrer only travels in the from param. The other Stats upsells record a view and a click, and without them there's no way to measure how many new sites pick free versus paid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in #113372 (74d79d4): stats_pricing_grid_view on mount plus stats_pricing_grid_paid_cta_clicked / stats_pricing_grid_free_cta_clicked, all through trackStatsAnalyticsEvent with blog_id, following #113364's pattern.

{ createInterpolateElement(
String(
translate(
'By clicking <strong>%(paid)s</strong> or <strong>%(free)s</strong>, you agree to our <tosLink>Terms of Service</tosLink> and to <shareDetailsLink>sync your site‘s data</shareDetailsLink> with us.',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

site‘s uses U+2018, the opening single quote, where the apostrophe U+2019 belongs. Worth correcting before this goes for translation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc90308 on #113372 — now U+2019.

? feature.name
: translate( '%s not included', { args: [ feature.name ], comment: 'Feature name' } )
);
const text = label ?? defaultLabel;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below the large breakpoint the feature-name column isn't rendered and defaultLabel switches to the feature name to compensate, but label is set unconditionally, so the four paid rows still read "Included" with nothing naming the feature. Same as the other pricing tables in the plugin so not new here — though the testing instructions describe the labels switching, which doesn't happen for those rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fc90308 on #113372 — dropped the redundant label on the three boolean differentiator rows so the mobile fallback names the feature; desktop is unchanged (the default label is already "Included", bolded via strong). The Locations rows keep their qualifiers on both viewports.

const freeLabel = String( translate( 'Start for free' ) );

// Starting for free is a plan choice: record the dismissal and reveal the dashboard.
const startForFree = () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Starting for free records the dismissal but doesn't claim jetpack_stats_free_yearly, so hasAnyPlan stays false and the site remains eligible — only the notice record keeps the grid away. Jetpack Search Free and the type: 'free' path in stats-purchase-checkout-redirect both run it through a zero-cost checkout. Was leaving it unclaimed deliberate?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, per the STATS-366 direction mirrored from Automattic/jetpack#51103: "Start for free" goes straight to the dashboard with no checkout for the free product, and the pricing_grid notice record is the mechanism that keeps the grid away. Running it through the zero-cost checkout would add a bounce to the free path; if claiming jetpack_stats_free_yearly becomes desirable later, the type: 'free' path in stats-purchase-checkout-redirect is the ready-made hook.

@kangzj
kangzj merged commit a38e1c2 into trunk Aug 7, 2026
27 checks passed
@kangzj
kangzj deleted the add/stats-pricing-grid branch August 7, 2026 05:58
@github-actions github-actions Bot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 7, 2026
recordDismissal();
queryClient.setQueryData(
[ 'stats', 'notices-visibility', 'raw', siteId ],
( notices: Notices | undefined ) => notices && { ...notices, pricing_grid: false }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the purchase page was reached without first rendering the pricing-grid gate, this raw notices query may not exist in the cache. In that case the updater returns undefined, so setQueryData does not create a local pricing_grid: false value. Both “I will do it later” handlers then navigate after a fixed 250 ms without awaiting the dismissal mutation; on a slow request, the newly mounted gate can issue its GET before the POST finishes and show the grid again. Could we either seed a normalized notices value when notices is undefined, or expose/await mutateAsync before navigating?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid on both counts — fixed in 57d1978 on #113372. The dismiss hook now returns the mutation promise (mutateAsync), and both skip handlers await it before navigating (still navigating if the request fails), so the gate's GET on the destination route can never read the pre-dismissal state. That closes the empty-cache case and also a variant this comment didn't cover: with the cache present but stale, the gate's background refetch could overwrite the patch with the pre-POST server value.

Went with awaiting rather than seeding: the raw notices entry is the full object shared by every notices consumer, so fabricating a normalized value on miss would feed StatsNotices made-up server state (e.g. the default tier_upgrade: true) as fresh data. The grid's own "Start for free" stays unawaited since its reveal is same-route (gate local state + the cache patch, which is guaranteed present there).

kangzj added a commit that referenced this pull request Aug 7, 2026
- Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks
  the store loaded with an empty list, which read as 'no plan' and would have
  shown the grid to a site that holds one. An error now means 'plan state
  unknown' and falls back to the dashboard.
- The price respects getCurrencyObject's symbolPosition, so locales that put
  the currency symbol after the amount (e.g. de-DE EUR) render correctly.
- The Terms of Service line wraps instead of truncating: dropped the jetpack
  component's nowrap/overflow-hidden, which cut off the disclosure in locales
  that run longer than English.
- Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening
  quote) before the string goes out for translation.
- Dropped the redundant 'Included' labels on the three boolean differentiator
  rows so the mobile fallback names the feature instead of showing a bare
  'Included' with nothing identifying the row. Desktop rendering is unchanged
  (the default label is already 'Included', bolded via strong).
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Follow up: Stats: add analytics events for the pricing grid

@a8ci18n

a8ci18n commented Aug 7, 2026

Copy link
Copy Markdown

This Pull Request is now available for translation here: https://translate.wordpress.com/deliverables/34683189

Some locales (Hebrew, Japanese) have been temporarily machine-translated due to translator availability. All other translations are usually ready within a few days. Untranslated and machine-translated strings will be sent for translation next Monday and are expected to be completed by the following Friday.

Thank you @kangzj for including a screenshot in the description! This is really helpful for our translators.

kangzj added a commit that referenced this pull request Aug 9, 2026
- Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks
  the store loaded with an empty list, which read as 'no plan' and would have
  shown the grid to a site that holds one. An error now means 'plan state
  unknown' and falls back to the dashboard.
- The price respects getCurrencyObject's symbolPosition, so locales that put
  the currency symbol after the amount (e.g. de-DE EUR) render correctly.
- The Terms of Service line wraps instead of truncating: dropped the jetpack
  component's nowrap/overflow-hidden, which cut off the disclosure in locales
  that run longer than English.
- Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening
  quote) before the string goes out for translation.
- Dropped the redundant 'Included' labels on the three boolean differentiator
  rows so the mobile fallback names the feature instead of showing a bare
  'Included' with nothing identifying the row. Desktop rendering is unchanged
  (the default label is already 'Included', bolded via strong).
kangzj added a commit that referenced this pull request Aug 9, 2026
* Stats pricing grid: add view and CTA analytics events

stats_pricing_grid_view fires on mount, stats_pricing_grid_paid_cta_clicked
and stats_pricing_grid_free_cta_clicked on the two CTAs — all through
trackStatsAnalyticsEvent (which prefixes jetpack_odyssey_/calypso_ by
surface) and all carrying blog_id, following the pattern from #113364.
The purchase page's skip button already has its own event.

* Stats pricing grid: address review feedback from #113366

- Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks
  the store loaded with an empty list, which read as 'no plan' and would have
  shown the grid to a site that holds one. An error now means 'plan state
  unknown' and falls back to the dashboard.
- The price respects getCurrencyObject's symbolPosition, so locales that put
  the currency symbol after the amount (e.g. de-DE EUR) render correctly.
- The Terms of Service line wraps instead of truncating: dropped the jetpack
  component's nowrap/overflow-hidden, which cut off the disclosure in locales
  that run longer than English.
- Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening
  quote) before the string goes out for translation.
- Dropped the redundant 'Included' labels on the three boolean differentiator
  rows so the mobile fallback names the feature instead of showing a bare
  'Included' with nothing identifying the row. Desktop rendering is unchanged
  (the default label is already 'Included', bolded via strong).

* Stats pricing grid: await the dismissal before navigating from the purchase page

The dismissal was fire-and-forget with a fixed 250 ms before navigating, so on
a slow request the gate mounted on the destination route could issue its
notices GET before the POST landed and re-render the grid after 'I will do it
later'. The cache patch couldn't cover this either: the raw notices entry is
absent when the purchase page was reached directly (nothing on it queries
notices) or when the dwell outlived the cache's gcTime, and setQueryData with
an updater returning undefined is a no-op — while seeding a fabricated notices
object would feed the other notice consumers made-up server state.

The dismiss hook now returns the mutation promise (mutateAsync); both skip
handlers await it before navigating, still navigating if the request fails.
The grid's free CTA stays same-route and unawaited, just absorbing the
rejection.

* Stats pricing grid: cap the awaited dismissal so the skip button never stalls

The dismissal mutation retries once after 3s, so awaiting it unbounded could
leave 'I will do it later' visually dead for many seconds on a flaky network.
Race it against a 2s cap: the healthy path (a fast POST) stays fully ordered
before navigation, while past the cap the request continues in flight and
navigation proceeds.

* Stats: self-correct notice dismissals with query invalidation on success

The mutation retries once after 3s, so any capped await on the dismissal
was arithmetically guaranteed to lose to a failed first POST, leaving the
stale pricing_grid state cached for up to 30s. Invalidating the notices
query from the mutation (mutation-level, since per-call callbacks are
skipped once the caller unmounts on navigation) self-corrects whenever
the POST lands, for every notice using this mutation. That makes the
awaited navigation redundant, so the skip handlers return to the plain
dismiss-and-navigate shape and the rejection handling moves into the
dismiss hook.

* Stats purchase: carry the referrer on the skip events

The dismissal now fires regardless of referrer, so without `from` a skip
that came from a module upsell is indistinguishable from one off the
pricing grid.

* Stats pricing grid: cover the eligibility hook with tests

Also widen the getPurchasesError comment: the field is shared across all
purchases actions, not just the site fetch — fail-closed either way.

* Stats pricing grid: record which CTA was clicked
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants